iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
Software Development

Kotlin Lambda 從零開始系列 第 16

Kotlin Lambda 從零開始 Day 16:sortedWith / compareBy / thenBy — 多欄位排序

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260807/201219480fj7uFk1cv.jpg

這篇文章會用 TDD 手刻 mySortedWithmyReversed,搞懂 Comparator 的組合方式。上一篇用 sortedBy 做單欄位排序,這篇處理多欄位的情境

Kotlin ↔ C# 對照表

Kotlin C# LINQ 備註
sortedWith(comparator) OrderBy().ThenBy() 鏈式 C# 用鏈式,Kotlin 用 Comparator 組合
compareBy { }.thenBy { } 同上 Kotlin 的 Comparator 建構 DSL
reversed() Reverse() List<T>.Reverse() 是原地反轉
Comparator<T> { a, b -> } Comparer<T>.Create(...) IComparer<T> 不能直接吃 Lambda
Comparator.then(other) 無對應,要自己刻 C# 的 BCL 沒有 IComparer 組合工具

C# 的多欄位排序寫法是 .OrderBy(x => x.Department).ThenBy(x => x.Salary),一路串下去。Kotlin 走不同的路:先組合出一個 Comparator,再一次餵給 sortedWith

Comparator 介面

day 15 出現過 compareBycompareByDescending,但沒有解釋背後的 Comparator 介面

public fun interface Comparator<T> {
    fun compare(a: T, b: T): Int
}

compare(a, b) 的回傳規則跟 ComparablecompareTo 一樣:負數代表 a 排前面,正數代表 b 排前面,零代表相等

差別在角色。Comparable 是型別自己實作的(「我知道怎麼跟同類比大小」),Comparator 是外部提供的(「我來告訴你們誰排前面」)

因為 Comparatorfun interface(只有一個抽象方法),可以用 Lambda 建立

val byAge = Comparator<Employee> { a, b -> a.age - b.age }
val bySalary = Comparator<Employee> { a, b -> a.salary - b.salary }

不過手寫 Lambda 容易出錯(減法在邊界值可能溢位),實務上用 compareBy 更安全

Kotlin 這邊兩個角色,C# 那邊拆成三個。Comparable<T>.compareTo 對應 IComparable<T>.CompareTo,都是型別自己會比;Comparator<T>.compare 對應 IComparer<T>.Compare,都是外部告訴你怎麼比。多出來的第三個是 Comparison<T> delegate

差別在建立方式。Kotlin 的 Comparatorfun interface,上面那兩行直接用 Lambda 就寫得出來。C# 的 IComparer<T> 是普通介面,不能拿 Lambda 賦值,要嘛用 Comparer<T>.Create 包一層(.NET Framework 4.5 起提供),要嘛自己寫一個 class 實作介面

var byAge = Comparer<Employee>.Create((a, b) => a.Age.CompareTo(b.Age));
var bySalary = Comparer<Employee>.Create((a, b) => a.Salary.CompareTo(b.Salary));

C# 這邊減法一樣會溢位,所以改用 CompareTo

能直接吃 Lambda 的是 Comparison<T>List<T>.Sort 有一個參數是 Comparison<T> 的多載,所以 list.Sort((a, b) => a.Age.CompareTo(b.Age)) 寫得出來。而 Comparer<T>.Create 做的事,就是把 Comparison<T> 轉成 IComparer<T>

TDD 實作 mySortedWith

Red:先寫測試

@Test
fun `sortedWith comparator by salary`() {
    val result = employees.mySortedWith(compareBy { it.salary })
    assertEquals("Frank", result[0].name)   // 55000
    assertEquals("Grace", result.last().name) // 92000
}

@Test
fun `sortedWith multi-field department then salary`() {
    val comparator = compareBy<Employee> { it.department }
        .thenBy { it.salary }
    val result = employees.mySortedWith(comparator)
    // Engineering: Bob(72000), Alice(85000), Grace(92000)
    assertEquals("Bob", result[0].name)
    assertEquals("Alice", result[1].name)
    assertEquals("Grace", result[2].name)
    // HR: Frank(55000), Eve(60000)
    assertEquals("Frank", result[3].name)
    assertEquals("Eve", result[4].name)
}

@Test
fun `sortedWith lambda comparator`() {
    val result = employees.mySortedWith(Comparator { a, b -> a.age - b.age })
    assertEquals("Bob", result[0].name)    // age 25
    assertEquals("Grace", result.last().name) // age 45
}

@Test
fun `sortedWith empty list returns empty list`() {
    val result = emptyList<Employee>().mySortedWith(compareBy { it.salary })
    assertEquals(emptyList<Employee>(), result)
}

@Test
fun `sortedWith single element list`() {
    val one = listOf(employees[0])
    val result = one.mySortedWith(compareBy { it.salary })
    assertEquals(one, result)
}

第二個測試是重點。先按部門排(字母序:Engineering < HR < Marketing),部門相同再按薪水排。compareBy 建立第一層比較,.thenBy 接上第二層

後面兩個是邊界案例:空集合排出來還是空集合,單一元素不用排也安全回傳。至於 null,mySortedWith 不像 mySorted 那樣靠泛型約束擋 null — 集合本身可以裝 Employee?,但 null 怎麼排是 Comparator 的責任。實務上會用 nullsFirst() / nullsLast() 包一層,這篇先不展開,重點放在 Comparator 的組合

Green:最小實作

fun <T> Iterable<T>.mySortedWith(comparator: Comparator<in T>): List<T> {
    val list = this.toMutableList()
    list.sortWith(comparator)
    return list
}

mySorted 結構一模一樣,差在接受一個外部的 Comparator 而不是依賴 T : Comparable<T>

注意參數型別是 Comparator<in T> 而不是 Comparator<T>in 是型變(variance)修飾,代表這個 Comparator 可以接受 T 的父型別。比方說 Comparator<Any> 能拿來排序 List<Employee>,因為 AnyEmployee 的父型別。day 34 會完整講型變,這裡先知道有這個東西就好

Refactor:往 stdlib 的寫法靠近

照 day 15 的手法,用 apply 縮成一行,這也是 stdlib sortedWith 在一般 Iterable 路徑上的寫法

fun <T> Iterable<T>.mySortedWith(comparator: Comparator<in T>): List<T> {
    return this.toMutableList().apply { sortWith(comparator) }
}

順便兌現 day 15 留下的承諾。當時 mySortedBy 的 Refactor 刻意停住,等的就是 mySortedWith — 現在它有了,mySortedBy 可以改寫成一行

inline fun <T, R : Comparable<R>> Iterable<T>.mySortedBy(
    crossinline selector: (T) -> R?
): List<T> {
    return mySortedWith(compareBy(selector))
}

兩個 descending 版本也一起完成。mySortedByDescendingcompareBy 換成 compareByDescending 就好,mySortedDescending 則是改用 day 15 提過的 reverseOrder(),不用再自己組一個反向 Comparator

fun <T : Comparable<T>> Iterable<T>.mySortedDescending(): List<T> {
    return mySortedWith(reverseOrder())
}

inline fun <T, R : Comparable<R>> Iterable<T>.mySortedByDescending(
    crossinline selector: (T) -> R?
): List<T> {
    return mySortedWith(compareByDescending(selector))
}

整個排序家族最後都收斂到 mySortedWith 這一個入口,等一下比較 stdlib 原始碼時會看到一樣的結構

compareBy + thenBy 的組合

compareBythenBy 不是我們手刻的(它們是建立 Comparator 的工具函式,不是 Collection 操作),但要理解它們怎麼運作

// 單欄位
val byDept = compareBy<Employee> { it.department }

// 多欄位:先部門,再薪水
val byDeptThenSalary = compareBy<Employee> { it.department }
    .thenBy { it.salary }

// 混合升降序:先部門升序,再薪水降序
val mixed = compareBy<Employee> { it.department }
    .thenByDescending { it.salary }

thenBy 回傳一個新的 Comparator,它的邏輯是:先用前一個 Comparator 比,如果相等(回傳 0)才用 thenBy 的 selector 比。這就是「多欄位排序」的本質:一連串的 fallback

stdlib 的 thenBy 就是這樣做的,它先呼叫前一個 Comparator,只有結果為 0 才用 selector 決勝負

public inline fun <T> Comparator<T>.thenBy(crossinline selector: (T) -> Comparable<*>?): Comparator<T> =
    Comparator { a, b ->
        val previousCompare = this@thenBy.compare(a, b)
        if (previousCompare != 0) previousCompare else compareValuesBy(a, b, selector)
    }

this@thenBy.compare(a, b) 就是「前一個現成的 Comparator」。所以它跟下面這個 then 的組合方式幾乎是同一件事,只是 thenBy 收的是 selector、then 收的是另一個 Comparator

// 邏輯等同的 then 組合
fun <T> Comparator<T>.then(other: Comparator<T>): Comparator<T> {
    return Comparator { a, b ->
        val result = this.compare(a, b)
        if (result != 0) result else other.compare(a, b)
    }
}

前一個比出結果就用前一個,比不出(等於 0)就往下一個問。可以一直串下去,想比幾個欄位就串幾個

C# 的 ThenBy 是查詢的一部分

Kotlin 的 compareBy<Employee> { it.department }.thenBy { it.salary } 是一個獨立物件,跟資料無關。可以存進變數、當參數傳來傳去

C# 的 ThenBy 不是這樣。它是 IOrderedEnumerable<T> 的 extension method,必須先有一個被 OrderByOrderByDescending 排過的序列,它才存在。沒辦法脫離資料,把 OrderBy().ThenBy() 存成一個「比較器」變數

想要一個可重用的多欄位比較器,得自己組

var byDeptThenSalary = Comparer<Employee>.Create((a, b) =>
{
    var r = string.CompareOrdinal(a.Department, b.Department);
    return r != 0 ? r : a.Salary.CompareTo(b.Salary);
});

list.Sort(byDeptThenSalary);                          // 原地排序,不回傳新 list
var sorted = list.OrderBy(x => x, byDeptThenSalary);  // 這個才對應 sortedWith

上面那個 then,Kotlin stdlib 本來就有現成的;C# 的 BCL 沒有,得自己刻。而且 extension method 在 C# 只能放在 static class 裡,不像 Kotlin 可以直接寫成 top-level 函式

public static class ComparerExtensions
{
    public static IComparer<T> Then<T>(this IComparer<T> first, IComparer<T> second) =>
        Comparer<T>.Create((a, b) =>
        {
            var r = first.Compare(a, b);
            return r != 0 ? r : second.Compare(a, b);
        });
}

日常寫 LINQ 查詢時 OrderBy().ThenBy() 很順,但只要想把排序規則抽出來共用,就得繞這一段路

真要挑一個對應 sortedWith 的,是 OrderBy(x => x, comparer),一樣回傳新序列、一樣 stable。List<T>.SortArray.Sort 雖然也收 IComparer<T>,但它們是原地排序,比較像 Kotlin 的 sortWith。另外 OrderBy 平常那個 OrderBy(x => x.Salary, comparer) 的用法收的是 IComparer<TKey>,比的是 selector 取出來的鍵,跟這裡比整個元素的 IComparer<T> 不是同一層

還有兩個從 C# 過來容易誤判的地方。LINQ 的 OrderBy 是 stable sort,List<T>.Sort 卻是 unstable(introsort),Kotlin 的 sortedWith 在 JVM 上走 TimSort 是 stable。多欄位排序如果從 LINQ 改用 List.Sort,相等元素的原始順序就不保證了

另一個是字串。C# 的 OrderBy(x => x.Department)Comparer<string>.Default,是 culture-sensitive(走 CurrentCulture);Kotlin 的 compareBy { it.department } 底層是 String.compareTo,比的是 UTF-16 code unit,屬於 ordinal。同一份資料兩邊可能排出不一樣的結果,C# 要 ordinal 得明講 StringComparer.Ordinal

TDD 實作 myReversed

Red:先寫測試

@Test
fun `reversed list`() {
    val numbers = listOf(1, 2, 3, 4, 5)
    val result = numbers.myReversed()
    assertEquals(listOf(5, 4, 3, 2, 1), result)
}

@Test
fun `reversed does not modify original`() {
    val numbers = listOf(1, 2, 3)
    val result = numbers.myReversed()
    assertEquals(listOf(3, 2, 1), result)
    assertEquals(listOf(1, 2, 3), numbers)
}

@Test
fun `reversed empty list returns empty list`() {
    assertEquals(emptyList<Int>(), emptyList<Int>().myReversed())
}

@Test
fun `reversed single element list`() {
    assertEquals(listOf(42), listOf(42).myReversed())
}

空集合反轉還是空集合,單一元素反轉等於自己 — 兩個邊界都該安全通過。myReversed 不挑元素型別,也沒有 null 相關的特殊行為要測,純粹把順序倒過來

Green:最小實作

fun <T> Iterable<T>.myReversed(): List<T> {
    val list = this.toMutableList()
    list.reverse()
    return list
}

reversed() 回傳新 List,reverse() 原地反轉。又是 day 15 講的過去分詞 vs 動詞原形的命名規則

Refactor:往 stdlib 的寫法靠近

這三行已經是 stdlib reversed() 的形狀 — 複製一份、原地反轉、回傳副本,stdlib 只多了一個「元素不超過一個就直接回傳」的捷徑。沒什麼好再動的,直接收工

reversed vs asReversed

stdlib 還有一個 asReversed(),行為不一樣

val original = mutableListOf(1, 2, 3)

val reversed = original.reversed()       // 複製一份再反轉
val asReversed = original.asReversed()   // 建立反向的 view

original.add(4)
println(reversed)    // [3, 2, 1]    不受影響
println(asReversed)  // [4, 3, 2, 1] 跟著變了!

reversed() 是完整複製,之後兩邊互不相干。asReversed() 是 view,底層還是同一份資料,原始 List 改了 view 也跟著動。效能好但要小心副作用

stdlib 把 view 跟 copy 都提供出來,是「讓使用者選」的設計哲學

C# LINQ 的 Reverse() 是 deferred execution 的 IEnumerable 操作,但要小心:它不是「邊走邊反向」。一旦開始疊代,它會先把整個來源序列緩衝到內部陣列,再從尾巴往前 yield。反轉本身就需要一份完整的 buffer,跟 Kotlin 的 reversed() 一樣得先有全部資料才能反過來。Java 的 Collections.reverse(list) 則是原地反轉。它收的是 java.util.List,Java 沒有唯讀清單這個型別、傳唯讀清單進去要到執行期才丟 UnsupportedOperationException;從 Kotlin 呼叫時型別系統會先擋下來,只能傳 MutableList

Reverse 這個名字在 C# 還藏了一個陷阱

var list = new List<int> { 1, 2, 3 };

list.Reverse();                          // List<T> 自己的方法,原地反轉,回傳 void
var r = list.AsEnumerable().Reverse();   // LINQ extension method,回傳新序列

同一個名字扛了兩種行為,只能靠「是不是 extension method」分辨。而多載解析會讓型別自己的方法優先勝出,在 List<T> 上直接寫 .Reverse() 拿到的一定是原地反轉那個。Kotlin 用 reversed / reverse 兩個名字把這件事講明白,就是 day 15 那條過去分詞 vs 動詞原形的規則。另外 C# 也沒有 asReversed() 這種共享 view 的東西

Kotlin 走「明確區分」這條路。reversed() = 完整複製,asReversed() = 共享 view。許多 asXxx() 函式不會複製元素,而是回傳原物件或一個輕量 wrapper,例如 asSequence()asReversed()asIterable()。但這是各 API 的行為,不能只靠 as 前綴推定所有函式都零配置

看到 asXxx() 時,可以先預期它不會複製全部元素,再查該函式文件確認是否建立 wrapper、是否共享底層資料

與 stdlib 原始碼比較

原始碼位置:kotlin.collections_Collections.ktkotlin.comparisonsComparisons.kt

public fun <T> Iterable<T>.sortedWith(comparator: Comparator<in T>): List<T> {
    if (this is Collection) {
        if (size <= 1) return this.toList()
        @Suppress("UNCHECKED_CAST")
        return (toTypedArray<Any?>() as Array<T>).apply { sortWith(comparator) }.asList()
    }
    return toMutableList().apply { sortWith(comparator) }
}

跟 day 15 看到的一樣。如果是 Collection 且只有 0 或 1 個元素,跳過排序直接回傳。否則轉成陣列排序(JVM 上陣列排序比 List 快)

其實 day 15 的 sortedBy 底層就是呼叫 sortedWith(compareBy(selector))。整個排序體系的終點都是 sortedWith,只是語法糖的層次不同

sortedBy { it.salary }
  → sortedWith(compareBy { it.salary })
    → toMutableList().sortWith(comparator)
      → java.util.Arrays.sort() (TimSort)

第四部分回顧

排序篇到這裡結束。兩篇走過的路線

sortedBy(單欄位,靠 Comparable) → sortedWith(多欄位,靠 Comparator 組合)

泛型約束也從 T : Comparable<T>(型別自己會比)演進到 Comparator<in T>(外部告訴你怎麼比)。這兩種模式在 Java/Kotlin 世界裡到處都是

本部分 API 速查

手刻函式 篇號 用途
mySorted day 15 依自然順序排序,要求 T : Comparable<T>
mySortedDescending day 15 依自然順序反向排序
mySortedBy day 15 依 selector 取出的鍵排序
mySortedByDescending day 15 依 selector 取出的鍵反向排序
mySortedWith day 16 依外部傳入的 Comparator 排序,撐起多欄位排序
myReversed day 16 複製一份再反轉順序

小結

排序篇兩篇手刻了六個函式,把「型別自己會比」和「外部告訴你怎麼比」兩種排序模式都走過一遍

下一篇進入聚合篇。day 17 從 fold 開始,Lambda 的簽名會變成 (acc, element) -> acc,帶著累加器一路滾下去

參考資料


Yes


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Lambda 從零開始 Day 15:sorted / sortedBy / sortedByDescending — 排序基礎
下一篇
Kotlin Lambda 從零開始 Day 17:fold / foldIndexed — 帶初始值的聚合
系列文
Kotlin Lambda 從零開始18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言